# Neo4j Knowledge Graph Complete Tutorial
## From Zero to Production Graph RAG - 100+ Page Practical Guide

**Version 2.0 | January 2025**  
**For Hackathons, Students, and Production Applications**

---

# Table of Contents

## Part I: Foundations (Sections 1-10)
1. [Introduction to Graph Databases](#1-introduction-to-graph-databases)
2. [Environment Setup](#2-environment-setup)
3. [Nodes, Labels, and Properties](#3-nodes-labels-and-properties)
4. [Relationships and Patterns](#4-relationships-and-patterns)
5. [Basic CRUD Operations](#5-basic-crud-operations)
6. [Advanced Cypher Queries](#6-advanced-cypher-queries)
7. [Indexing and Constraints](#7-indexing-and-constraints)
8. [Transaction Management](#8-transaction-management)
9. [Batch Operations and Import](#9-batch-operations-and-import)
10. [Schema Design Patterns](#10-schema-design-patterns)

## Part II: Graph Data Science (Sections 11-17)
11. [GDS Library Fundamentals](#11-gds-library-fundamentals)
12. [Centrality Algorithms](#12-centrality-algorithms)
13. [Community Detection](#13-community-detection)
14. [Pathfinding Algorithms](#14-pathfinding-algorithms)
15. [Similarity Algorithms](#15-similarity-algorithms)
16. [Link Prediction](#16-link-prediction)
17. [Graph Embeddings](#17-graph-embeddings)

## Part III: Knowledge Graphs (Sections 18-25)
18. [Ontology Design](#18-ontology-design)
19. [Populating Knowledge Graphs](#19-populating-knowledge-graphs)
20. [Document Chunking Strategies](#20-document-chunking-strategies)
21. [Vector Embeddings](#21-vector-embeddings)
22. [HNSW Vector Indexing](#22-hnsw-vector-indexing)
23. [Graph RAG Implementation](#23-graph-rag-implementation)
24. [Advanced Graph RAG Patterns](#24-advanced-graph-rag-patterns)
25. [LLM Integration](#25-llm-integration)

## Part IV: Production Systems (Sections 26-30)
26. [Large-Scale Graph Clustering](#26-large-scale-graph-clustering)
27. [Neo4j Internal Architecture](#27-neo4j-internal-architecture)
28. [Performance Optimization](#28-performance-optimization)
29. [Enterprise Deployment](#29-enterprise-deployment)
30. [REST API Development](#30-rest-api-development)

## Appendices
- [A. Complete Code Reference](#appendix-a-complete-code-reference)
- [B. Cypher Cheat Sheet](#appendix-b-cypher-cheat-sheet)
- [C. Troubleshooting Guide](#appendix-c-troubleshooting-guide)
- [D. Project File Structure](#appendix-d-project-file-structure)

---

# Part I: Foundations

---

# 1. Introduction to Graph Databases

## 1.1 What is a Graph Database?

A graph database is a database designed to treat relationships between data as equally important as the data itself. It stores data as **nodes** (entities), **relationships** (connections between entities), and **properties** (attributes of nodes and relationships).

### The Property Graph Model

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                         PROPERTY GRAPH MODEL                                │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│                            ┌─────────────────────┐                          │
│                            │       NODE          │                          │
│                            │  Labels: [:Patient] │                          │
│                            │                     │                          │
│                            │  Properties:        │                          │
│                            │    id: "P001"       │                          │
│                            │    name: "John"     │                          │
│                            │    age: 45          │                          │
│                            │    active: true     │                          │
│                            └──────────┬──────────┘                          │
│                                       │                                     │
│                                       │                                     │
│                    ┌──────────────────┴───────────────────┐                 │
│                    │         RELATIONSHIP                 │                 │
│                    │  Type: DIAGNOSED_WITH                │                 │
│                    │  Direction: Patient → Disease        │                 │
│                    │                                      │                 │
│                    │  Properties:                         │                 │
│                    │    diagnosedDate: 2024-01-15         │                 │
│                    │    severity: "moderate"              │                 │
│                    │    diagnosedBy: "Dr. Smith"          │                 │
│                    └──────────────────┬───────────────────┘                 │
│                                       │                                     │
│                                       ▼                                     │
│                            ┌─────────────────────┐                          │
│                            │       NODE          │                          │
│                            │  Labels: [:Disease] │                          │
│                            │                     │                          │
│                            │  Properties:        │                          │
│                            │    id: "E11"        │                          │
│                            │    name: "Diabetes" │                          │
│                            │    category: "Endo" │                          │
│                            └─────────────────────┘                          │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

### Graph vs Relational Database Comparison

| Aspect | Relational (SQL) | Graph (Neo4j) |
|--------|------------------|---------------|
| **Data Model** | Tables with rows and columns | Nodes, relationships, properties |
| **Schema** | Fixed, predefined schema | Flexible, schema-optional |
| **Relationships** | Foreign keys, JOINs at runtime | First-class citizens, pre-computed |
| **JOIN Performance** | Degrades with depth (O(n²) or worse) | Constant for local queries |
| **Query Language** | SQL | Cypher |
| **Best For** | Structured, tabular data | Connected, relationship-rich data |

### When to Use a Graph Database

**Use a graph database when:**

1. **Relationships matter more than individual records**
   - Social networks (friends, followers)
   - Recommendation engines
   - Fraud detection networks

2. **Data is highly interconnected**
   - Knowledge graphs
   - Network topology
   - Supply chain management

3. **Query patterns involve traversal**
   - "Find all friends of friends who like jazz"
   - "What's the shortest path between A and B?"
   - "Find communities in the network"

4. **Schema evolves frequently**
   - Prototyping and agile development
   - Research applications
   - Multi-tenant systems

## 1.2 Why Neo4j?

Neo4j is the world's leading graph database platform, trusted by thousands of organizations including NASA, eBay, and Walmart.

### Key Features

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                           NEO4J FEATURES                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐             │
│  │   Native Graph  │  │    Cypher       │  │     ACID        │             │
│  │    Storage      │  │  Query Lang     │  │  Transactions   │             │
│  │                 │  │                 │  │                 │             │
│  │  Index-free     │  │  Intuitive &    │  │  Full ACID      │             │
│  │  adjacency      │  │  expressive     │  │  compliance     │             │
│  └─────────────────┘  └─────────────────┘  └─────────────────┘             │
│                                                                             │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐             │
│  │  Graph Data     │  │   Vector        │  │  Enterprise     │             │
│  │  Science (GDS)  │  │   Search        │  │   Features      │             │
│  │                 │  │                 │  │                 │             │
│  │  75+ algorithms │  │  HNSW index     │  │  Clustering,    │             │
│  │  centrality,    │  │  for RAG &      │  │  backup,        │             │
│  │  community etc  │  │  similarity     │  │  security       │             │
│  └─────────────────┘  └─────────────────┘  └─────────────────┘             │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

### Neo4j Architecture Overview

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                        NEO4J ARCHITECTURE                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  Application Layer                                                          │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  Python Driver  │  REST API  │  Bolt Protocol  │  Browser         │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                              │                                              │
│                              ▼                                              │
│  Query Processing                                                           │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  Cypher Parser  │  Query Planner  │  Query Optimizer  │  Executor  │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                              │                                              │
│                              ▼                                              │
│  Transaction Layer                                                          │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  Transaction Manager  │  Lock Manager  │  Recovery  │  WAL         │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                              │                                              │
│                              ▼                                              │
│  Storage Layer                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  Node Store  │  Relationship Store  │  Property Store  │  Labels   │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

## 1.3 Understanding Cypher Query Language

Cypher is Neo4j's declarative query language, designed to be intuitive and visually represent graph patterns.

### Basic Cypher Syntax

```cypher
// Node pattern - matches nodes with label Person
(p:Person)

// Relationship pattern - matches any relationship
-[r]->

// Combined pattern - matches Person named "John" who KNOWS someone
(p:Person {name: "John"})-[:KNOWS]->(friend:Person)

// Complete query - find and return John's friends
MATCH (p:Person {name: "John"})-[:KNOWS]->(friend:Person)
RETURN friend.name
```

### Cypher Keywords Quick Reference

| Keyword | Purpose | Example |
|---------|---------|---------|
| `MATCH` | Pattern matching | `MATCH (n:Person)` |
| `CREATE` | Create nodes/relationships | `CREATE (n:Person {name: "John"})` |
| `MERGE` | Match or create | `MERGE (n:Person {id: "P001"})` |
| `WHERE` | Filter results | `WHERE n.age > 30` |
| `RETURN` | Output results | `RETURN n.name, n.age` |
| `SET` | Update properties | `SET n.status = "active"` |
| `DELETE` | Delete nodes/relationships | `DELETE n` |
| `WITH` | Chain query parts | `WITH n, count(*) AS cnt` |
| `ORDER BY` | Sort results | `ORDER BY n.name DESC` |
| `LIMIT` | Limit results | `LIMIT 10` |

---

# 2. Environment Setup

## 2.1 Prerequisites

Before starting, ensure you have the following installed:

- **Python 3.9+** (3.11 recommended)
- **Docker** (for running Neo4j)
- **pip** (Python package manager)
- **Git** (optional, for cloning the repository)

## 2.2 Project Structure

```
neo4j-knowledge-graph-tutorial/
├── README.md                           # Project overview
├── NEO4J_COMPLETE_TUTORIAL.md          # This tutorial (100+ pages)
├── requirements.txt                    # Python dependencies
├── docker-compose.yml                  # Neo4j container setup
├── .env.example                        # Environment variables template
├── .env                                # Your local environment variables
│
├── src/                                # Source code (numbered sequentially)
│   ├── 01_connection_basics.py         # Database connection
│   ├── 02_nodes_and_properties.py      # Node CRUD operations
│   ├── 03_relationships.py             # Relationship operations
│   ├── 04_basic_queries.py             # Cypher queries
│   ├── 05_advanced_queries.py          # Complex patterns
│   ├── 06_indexing_and_constraints.py  # Index management
│   ├── 07_data_loader.py               # Data import utilities
│   ├── 08_transaction_management.py    # Transaction handling
│   ├── 09_batch_operations.py          # Bulk operations
│   ├── 10_gds_basics.py                # GDS introduction
│   ├── 11_centrality_algorithms.py     # PageRank, Betweenness
│   ├── 12_community_detection.py       # Louvain, Label Propagation
│   ├── 13_similarity_algorithms.py     # Node similarity
│   ├── 14_link_prediction.py           # Predict new relationships
│   ├── 15_pathfinding_algorithms.py    # Shortest path, A*
│   ├── 16_node_embeddings.py           # FastRP, Node2Vec
│   ├── 17_graph_embeddings.py          # Graph-level embeddings
│   ├── 18_ontology_design.py           # Knowledge graph schema
│   ├── 19_populate_ontology.py         # Data population
│   ├── 20_chunking_strategies.py       # Document chunking
│   ├── 21_vector_embeddings.py         # Text embeddings
│   ├── 22_hnsw_indexing.py             # Vector indexing
│   ├── 23_graph_rag_basics.py          # Basic RAG
│   ├── 24_advanced_graph_rag.py        # Advanced RAG patterns
│   ├── 25_llm_integration.py           # LLM with Neo4j
│   ├── 26_large_scale_clustering.py    # 10K+ node handling
│   ├── 27_neo4j_internals.py           # Storage internals
│   ├── 28_performance_optimization.py  # Query optimization
│   ├── 29_deployment.py                # Production deployment
│   ├── 30_api_server.py                # REST API with FastAPI
│   └── utils/
│       ├── __init__.py
│       └── connection.py               # Connection utilities
│
├── data/
│   └── sample_healthcare_data.json     # Sample data
│
├── examples/
│   ├── healthcare_kg/                  # Healthcare domain example
│   └── document_rag/                   # Document RAG example
│
├── tests/                              # Unit tests
│   └── test_connection.py
│
└── config/                             # Configuration files
    └── neo4j.conf
```

## 2.3 Docker Setup

Create `docker-compose.yml`:

```yaml
version: '3.8'

services:
  neo4j:
    image: neo4j:5.15.0-enterprise
    container_name: neo4j-tutorial
    ports:
      - "7474:7474"    # HTTP (Browser)
      - "7687:7687"    # Bolt (Driver)
    environment:
      - NEO4J_AUTH=neo4j/your_secure_password_here
      - NEO4J_ACCEPT_LICENSE_AGREEMENT=yes
      - NEO4J_PLUGINS=["graph-data-science", "apoc"]
      
      # Memory Configuration (adjust based on your system)
      - NEO4J_dbms_memory_pagecache_size=2G
      - NEO4J_dbms_memory_heap_initial__size=2G
      - NEO4J_dbms_memory_heap_max__size=2G
      
      # Enable procedures
      - NEO4J_dbms_security_procedures_unrestricted=gds.*,apoc.*
      - NEO4J_dbms_security_procedures_allowlist=gds.*,apoc.*
      
      # Vector index settings
      - NEO4J_dbms_security_allow__csv__import__from__file__urls=true
      
    volumes:
      - neo4j_data:/data
      - neo4j_logs:/logs
      - neo4j_import:/var/lib/neo4j/import
      - neo4j_plugins:/plugins
    
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:7474"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  neo4j_data:
  neo4j_logs:
  neo4j_import:
  neo4j_plugins:
```

Start Neo4j:

```bash
# Start the container
docker-compose up -d

# Check logs
docker-compose logs -f neo4j

# Verify it's running
docker-compose ps

# Stop when done
docker-compose down

# Stop and remove data
docker-compose down -v
```

## 2.4 Python Dependencies

Create `requirements.txt`:

```text
# Core Neo4j
neo4j==5.15.0
graphdatascience==1.7.0

# Vector Embeddings
sentence-transformers==2.2.2
openai==1.12.0

# Data Processing
numpy==1.24.0
pandas==2.0.0
scipy==1.11.0

# API Development
fastapi==0.109.0
uvicorn==0.27.0
pydantic==2.6.0

# Utilities
python-dotenv==1.0.0
tqdm==4.66.0
tenacity==8.2.3

# LLM Integration
langchain==0.1.0
langchain-community==0.0.13
anthropic==0.18.0

# Testing
pytest==7.4.0
pytest-asyncio==0.21.0

# Document Processing
pypdf==3.17.0
unstructured==0.12.0
```

Install dependencies:

```bash
# Create virtual environment
python -m venv venv

# Activate (Linux/Mac)
source venv/bin/activate

# Activate (Windows)
venv\Scripts\activate

# Install dependencies
pip install -r requirements.txt
```

## 2.5 Environment Configuration

Create `.env.example`:

```bash
# Neo4j Connection
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=your_secure_password_here

# OpenAI (optional, for advanced embeddings)
OPENAI_API_KEY=your_openai_api_key_here

# Anthropic (optional, for Claude integration)
ANTHROPIC_API_KEY=your_anthropic_api_key_here

# Application Settings
LOG_LEVEL=INFO
BATCH_SIZE=1000
```

Copy to `.env` and update with your credentials:

```bash
cp .env.example .env
# Edit .env with your actual values
```

## 2.6 Verify Installation

Create and run `verify_setup.py`:

```python
#!/usr/bin/env python3
"""
verify_setup.py - Verify Neo4j installation and dependencies
Run: python verify_setup.py
"""

import sys
from neo4j import GraphDatabase
from dotenv import load_dotenv
import os

def check_python_version():
    """Check Python version."""
    print(f"Python version: {sys.version}")
    if sys.version_info < (3, 9):
        print("❌ Python 3.9+ required")
        return False
    print("✅ Python version OK")
    return True

def check_dependencies():
    """Check required packages."""
    packages = [
        'neo4j',
        'graphdatascience',
        'sentence_transformers',
        'fastapi',
        'pandas',
        'numpy'
    ]
    
    all_ok = True
    for package in packages:
        try:
            __import__(package)
            print(f"✅ {package} installed")
        except ImportError:
            print(f"❌ {package} NOT installed")
            all_ok = False
    
    return all_ok

def check_neo4j_connection():
    """Test Neo4j connection."""
    load_dotenv()
    
    uri = os.getenv('NEO4J_URI', 'bolt://localhost:7687')
    user = os.getenv('NEO4J_USER', 'neo4j')
    password = os.getenv('NEO4J_PASSWORD', 'password')
    
    try:
        driver = GraphDatabase.driver(uri, auth=(user, password))
        with driver.session() as session:
            result = session.run("RETURN 1 AS test")
            record = result.single()
            if record['test'] == 1:
                print(f"✅ Neo4j connection OK ({uri})")
                
                # Check GDS
                try:
                    gds_result = session.run("RETURN gds.version() AS version")
                    gds_version = gds_result.single()['version']
                    print(f"✅ GDS version: {gds_version}")
                except Exception as e:
                    print(f"⚠️ GDS not available: {e}")
                
                # Check APOC
                try:
                    apoc_result = session.run(
                        "RETURN apoc.version() AS version"
                    )
                    apoc_version = apoc_result.single()['version']
                    print(f"✅ APOC version: {apoc_version}")
                except Exception as e:
                    print(f"⚠️ APOC not available: {e}")
                
                driver.close()
                return True
    except Exception as e:
        print(f"❌ Neo4j connection failed: {e}")
        return False

def main():
    """Run all checks."""
    print("=" * 60)
    print("NEO4J TUTORIAL SETUP VERIFICATION")
    print("=" * 60)
    print()
    
    results = []
    
    print("1. Checking Python version...")
    results.append(check_python_version())
    print()
    
    print("2. Checking dependencies...")
    results.append(check_dependencies())
    print()
    
    print("3. Checking Neo4j connection...")
    results.append(check_neo4j_connection())
    print()
    
    print("=" * 60)
    if all(results):
        print("✅ ALL CHECKS PASSED - Ready to start!")
    else:
        print("❌ SOME CHECKS FAILED - Please fix issues above")
    print("=" * 60)

if __name__ == "__main__":
    main()
```

---

# 3. Nodes, Labels, and Properties

## 3.1 Understanding Nodes

Nodes are the fundamental units in a graph database. Each node represents an entity in your domain.

### Node Anatomy

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                              NODE ANATOMY                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│    ┌────────────────────────────────────────────────────────────────┐      │
│    │                         NODE                                   │      │
│    │                                                                │      │
│    │   Internal ID: 42 (auto-generated, not for external use)       │      │
│    │                                                                │      │
│    │   Labels: [:Patient, :Person, :Entity]                        │      │
│    │           (Categories/Types - can have multiple)               │      │
│    │                                                                │      │
│    │   Properties: {                                                │      │
│    │       id: "P001"           // String (your business ID)        │      │
│    │       name: "John Doe"     // String                           │      │
│    │       age: 45              // Integer                          │      │
│    │       weight: 78.5         // Float                            │      │
│    │       active: true         // Boolean                          │      │
│    │       birthDate: date('1979-03-15')  // Date                   │      │
│    │       createdAt: datetime()          // DateTime               │      │
│    │       location: point({...})         // Geospatial             │      │
│    │       tags: ["diabetic", "hypertension"]  // List              │      │
│    │   }                                                            │      │
│    │                                                                │      │
│    └────────────────────────────────────────────────────────────────┘      │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

### Property Types in Neo4j

| Type | Cypher Example | Python Equivalent |
|------|----------------|-------------------|
| String | `"John Doe"` | `str` |
| Integer | `42` | `int` |
| Float | `3.14` | `float` |
| Boolean | `true` / `false` | `bool` |
| Date | `date('2024-01-15')` | `datetime.date` |
| DateTime | `datetime('2024-01-15T10:30:00')` | `datetime.datetime` |
| Time | `time('10:30:00')` | `datetime.time` |
| Point | `point({latitude: 40.7, longitude: -74.0})` | dict |
| List | `['a', 'b', 'c']` | `list` |

## 3.2 Creating Nodes with Python

### File: `src/02_nodes_and_properties.py`

```python
#!/usr/bin/env python3
"""
02_nodes_and_properties.py - Node CRUD Operations in Neo4j

This module demonstrates:
- Creating nodes with labels and properties
- Using dataclasses for type-safe models
- CRUD operations (Create, Read, Update, Delete)
- Batch node creation with UNWIND
- Property management and label operations

Run: python src/02_nodes_and_properties.py
"""

from dataclasses import dataclass, field, asdict
from datetime import date, datetime
from typing import Optional, List, Dict, Any
from neo4j import GraphDatabase
import os
from dotenv import load_dotenv

load_dotenv()

# =============================================================================
# DATA MODELS
# =============================================================================

@dataclass
class Patient:
    """Patient entity model with healthcare-specific properties."""
    
    id: str                                    # Business ID (e.g., "P001")
    name: str                                  # Full name
    date_of_birth: date                        # DOB for age calculation
    gender: str                                # M/F/Other
    blood_type: Optional[str] = None           # A+, B-, O+, etc.
    email: Optional[str] = None                # Contact email
    phone: Optional[str] = None                # Contact phone
    address: Optional[str] = None              # Address
    emergency_contact: Optional[str] = None    # Emergency contact
    insurance_id: Optional[str] = None         # Insurance ID
    active: bool = True                        # Is patient active?
    created_at: datetime = field(default_factory=datetime.now)
    tags: List[str] = field(default_factory=list)  # Tags for categorization
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for Cypher parameters."""
        data = asdict(self)
        # Convert date/datetime to ISO format strings for Neo4j
        data['date_of_birth'] = self.date_of_birth.isoformat()
        data['created_at'] = self.created_at.isoformat()
        return data


@dataclass
class Disease:
    """Disease entity with medical coding (ICD-11)."""
    
    id: str                      # ICD-11 code (e.g., "E11" for Type 2 Diabetes)
    name: str                    # Common name
    category: str                # Category (e.g., "Endocrine")
    description: Optional[str] = None
    symptoms: List[str] = field(default_factory=list)
    risk_factors: List[str] = field(default_factory=list)
    icd10_code: Optional[str] = None  # Legacy ICD-10 code
    chronic: bool = False        # Is it a chronic condition?
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for Cypher parameters."""
        return asdict(self)


@dataclass
class Medication:
    """Medication entity with RxNorm coding."""
    
    id: str                      # RxNorm code (e.g., "6809" for Metformin)
    name: str                    # Generic name
    brand_names: List[str] = field(default_factory=list)
    drug_class: Optional[str] = None
    dosage_forms: List[str] = field(default_factory=list)
    contraindications: List[str] = field(default_factory=list)
    side_effects: List[str] = field(default_factory=list)
    requires_prescription: bool = True
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for Cypher parameters."""
        return asdict(self)


# =============================================================================
# NODE MANAGER CLASS
# =============================================================================

class NodeManager:
    """
    Manages node CRUD operations in Neo4j.
    
    This class provides methods for:
    - Creating single and batch nodes
    - Reading/searching nodes
    - Updating properties
    - Managing labels
    - Deleting nodes
    """
    
    def __init__(self, uri: str, user: str, password: str):
        """Initialize connection to Neo4j."""
        self.driver = GraphDatabase.driver(uri, auth=(user, password))
        print(f"✓ Connected to Neo4j at {uri}")
    
    def close(self):
        """Close the driver connection."""
        self.driver.close()
        print("✓ Connection closed")
    
    def __enter__(self):
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
    
    # -------------------------------------------------------------------------
    # CREATE OPERATIONS
    # -------------------------------------------------------------------------
    
    def create_patient(self, patient: Patient) -> Dict[str, Any]:
        """
        Create a single patient node.
        
        Uses MERGE to avoid duplicates based on the id property.
        
        Args:
            patient: Patient dataclass instance
            
        Returns:
            Dictionary with created node properties
        """
        query = """
        MERGE (p:Patient {id: $id})
        ON CREATE SET
            p.name = $name,
            p.date_of_birth = date($date_of_birth),
            p.gender = $gender,
            p.blood_type = $blood_type,
            p.email = $email,
            p.phone = $phone,
            p.address = $address,
            p.emergency_contact = $emergency_contact,
            p.insurance_id = $insurance_id,
            p.active = $active,
            p.created_at = datetime($created_at),
            p.tags = $tags
        ON MATCH SET
            p.name = $name,
            p.date_of_birth = date($date_of_birth),
            p.gender = $gender,
            p.blood_type = $blood_type,
            p.email = $email,
            p.phone = $phone,
            p.address = $address,
            p.emergency_contact = $emergency_contact,
            p.insurance_id = $insurance_id,
            p.active = $active,
            p.tags = $tags
        RETURN p {.*} AS patient
        """
        
        with self.driver.session() as session:
            result = session.run(query, patient.to_dict())
            record = result.single()
            print(f"✓ Created/Updated patient: {patient.name} ({patient.id})")
            return record['patient']
    
    def create_disease(self, disease: Disease) -> Dict[str, Any]:
        """
        Create a disease node with ICD-11 coding.
        
        Args:
            disease: Disease dataclass instance
            
        Returns:
            Dictionary with created node properties
        """
        query = """
        MERGE (d:Disease {id: $id})
        ON CREATE SET
            d.name = $name,
            d.category = $category,
            d.description = $description,
            d.symptoms = $symptoms,
            d.risk_factors = $risk_factors,
            d.icd10_code = $icd10_code,
            d.chronic = $chronic
        ON MATCH SET
            d.name = $name,
            d.category = $category,
            d.description = $description,
            d.symptoms = $symptoms,
            d.risk_factors = $risk_factors,
            d.icd10_code = $icd10_code,
            d.chronic = $chronic
        RETURN d {.*} AS disease
        """
        
        with self.driver.session() as session:
            result = session.run(query, disease.to_dict())
            record = result.single()
            print(f"✓ Created/Updated disease: {disease.name} ({disease.id})")
            return record['disease']
    
    def create_medication(self, medication: Medication) -> Dict[str, Any]:
        """
        Create a medication node with RxNorm coding.
        
        Args:
            medication: Medication dataclass instance
            
        Returns:
            Dictionary with created node properties
        """
        query = """
        MERGE (m:Medication {id: $id})
        ON CREATE SET
            m.name = $name,
            m.brand_names = $brand_names,
            m.drug_class = $drug_class,
            m.dosage_forms = $dosage_forms,
            m.contraindications = $contraindications,
            m.side_effects = $side_effects,
            m.requires_prescription = $requires_prescription
        ON MATCH SET
            m.name = $name,
            m.brand_names = $brand_names,
            m.drug_class = $drug_class,
            m.dosage_forms = $dosage_forms,
            m.contraindications = $contraindications,
            m.side_effects = $side_effects,
            m.requires_prescription = $requires_prescription
        RETURN m {.*} AS medication
        """
        
        with self.driver.session() as session:
            result = session.run(query, medication.to_dict())
            record = result.single()
            print(f"✓ Created/Updated medication: {medication.name} ({medication.id})")
            return record['medication']
    
    def bulk_create_patients(
        self, 
        patients: List[Patient], 
        batch_size: int = 1000
    ) -> int:
        """
        Bulk create patients using UNWIND for efficiency.
        
        UNWIND converts a list into individual rows, allowing batch processing
        in a single transaction. This is much faster than individual creates.
        
        Args:
            patients: List of Patient instances
            batch_size: Number of patients per batch
            
        Returns:
            Total number of patients created
        """
        query = """
        UNWIND $patients AS patient
        MERGE (p:Patient {id: patient.id})
        ON CREATE SET
            p.name = patient.name,
            p.date_of_birth = date(patient.date_of_birth),
            p.gender = patient.gender,
            p.blood_type = patient.blood_type,
            p.email = patient.email,
            p.phone = patient.phone,
            p.active = patient.active,
            p.created_at = datetime(patient.created_at),
            p.tags = patient.tags
        ON MATCH SET
            p.name = patient.name,
            p.date_of_birth = date(patient.date_of_birth),
            p.gender = patient.gender,
            p.blood_type = patient.blood_type,
            p.email = patient.email,
            p.phone = patient.phone,
            p.active = patient.active,
            p.tags = patient.tags
        RETURN count(p) AS count
        """
        
        total_created = 0
        patient_dicts = [p.to_dict() for p in patients]
        
        with self.driver.session() as session:
            # Process in batches
            for i in range(0, len(patient_dicts), batch_size):
                batch = patient_dicts[i:i + batch_size]
                result = session.run(query, {'patients': batch})
                count = result.single()['count']
                total_created += count
                print(f"  Batch {i//batch_size + 1}: {count} patients")
        
        print(f"✓ Bulk created {total_created} patients total")
        return total_created
    
    # -------------------------------------------------------------------------
    # READ OPERATIONS
    # -------------------------------------------------------------------------
    
    def get_patient(self, patient_id: str) -> Optional[Dict[str, Any]]:
        """
        Get a patient by ID.
        
        Args:
            patient_id: The patient's business ID
            
        Returns:
            Patient properties dict or None if not found
        """
        query = """
        MATCH (p:Patient {id: $id})
        RETURN p {.*} AS patient
        """
        
        with self.driver.session() as session:
            result = session.run(query, {'id': patient_id})
            record = result.single()
            return record['patient'] if record else None
    
    def search_patients(
        self,
        name_contains: Optional[str] = None,
        gender: Optional[str] = None,
        active: Optional[bool] = None,
        min_age: Optional[int] = None,
        max_age: Optional[int] = None,
        tags: Optional[List[str]] = None,
        limit: int = 100
    ) -> List[Dict[str, Any]]:
        """
        Search patients with flexible filters.
        
        Args:
            name_contains: Partial name match (case-insensitive)
            gender: Filter by gender
            active: Filter by active status
            min_age: Minimum age
            max_age: Maximum age
            tags: Filter by any of these tags
            limit: Maximum results to return
            
        Returns:
            List of matching patient dictionaries
        """
        # Build dynamic query with optional filters
        conditions = []
        params = {'limit': limit}
        
        if name_contains:
            conditions.append("toLower(p.name) CONTAINS toLower($name_contains)")
            params['name_contains'] = name_contains
        
        if gender:
            conditions.append("p.gender = $gender")
            params['gender'] = gender
        
        if active is not None:
            conditions.append("p.active = $active")
            params['active'] = active
        
        if min_age is not None:
            conditions.append(
                "duration.between(p.date_of_birth, date()).years >= $min_age"
            )
            params['min_age'] = min_age
        
        if max_age is not None:
            conditions.append(
                "duration.between(p.date_of_birth, date()).years <= $max_age"
            )
            params['max_age'] = max_age
        
        if tags:
            conditions.append("any(tag IN p.tags WHERE tag IN $tags)")
            params['tags'] = tags
        
        # Construct query
        where_clause = " AND ".join(conditions) if conditions else "1=1"
        query = f"""
        MATCH (p:Patient)
        WHERE {where_clause}
        RETURN p {{.*}} AS patient
        ORDER BY p.name
        LIMIT $limit
        """
        
        with self.driver.session() as session:
            result = session.run(query, params)
            patients = [record['patient'] for record in result]
            print(f"✓ Found {len(patients)} patients matching criteria")
            return patients
    
    def count_nodes_by_label(self, label: str) -> int:
        """
        Count nodes with a specific label.
        
        Args:
            label: The node label to count
            
        Returns:
            Count of nodes with the label
        """
        query = f"MATCH (n:{label}) RETURN count(n) AS count"
        
        with self.driver.session() as session:
            result = session.run(query)
            count = result.single()['count']
            print(f"✓ Count of {label} nodes: {count}")
            return count
    
    # -------------------------------------------------------------------------
    # UPDATE OPERATIONS
    # -------------------------------------------------------------------------
    
    def update_patient_properties(
        self, 
        patient_id: str, 
        updates: Dict[str, Any]
    ) -> Optional[Dict[str, Any]]:
        """
        Update specific properties of a patient.
        
        Uses SET with map syntax for dynamic property updates.
        
        Args:
            patient_id: The patient's ID
            updates: Dictionary of properties to update
            
        Returns:
            Updated patient dict or None if not found
        """
        # Build SET clause dynamically
        set_clauses = []
        params = {'id': patient_id}
        
        for key, value in updates.items():
            param_name = f"update_{key}"
            set_clauses.append(f"p.{key} = ${param_name}")
            params[param_name] = value
        
        if not set_clauses:
            return None
        
        query = f"""
        MATCH (p:Patient {{id: $id}})
        SET {', '.join(set_clauses)}
        RETURN p {{.*}} AS patient
        """
        
        with self.driver.session() as session:
            result = session.run(query, params)
            record = result.single()
            if record:
                print(f"✓ Updated patient {patient_id}: {list(updates.keys())}")
                return record['patient']
            return None
    
    def add_label(self, patient_id: str, label: str) -> bool:
        """
        Add a label to an existing patient node.
        
        Useful for categorization (e.g., :HighRisk, :Diabetic).
        
        Args:
            patient_id: The patient's ID
            label: Label to add
            
        Returns:
            True if label was added, False if patient not found
        """
        # Note: Labels can't be parameterized, so we validate first
        if not label.isalnum():
            raise ValueError("Label must be alphanumeric")
        
        query = f"""
        MATCH (p:Patient {{id: $id}})
        SET p:{label}
        RETURN p
        """
        
        with self.driver.session() as session:
            result = session.run(query, {'id': patient_id})
            if result.single():
                print(f"✓ Added label :{label} to patient {patient_id}")
                return True
            return False
    
    def remove_label(self, patient_id: str, label: str) -> bool:
        """
        Remove a label from a patient node.
        
        Args:
            patient_id: The patient's ID
            label: Label to remove
            
        Returns:
            True if label was removed, False if patient not found
        """
        if not label.isalnum():
            raise ValueError("Label must be alphanumeric")
        
        query = f"""
        MATCH (p:Patient {{id: $id}})
        REMOVE p:{label}
        RETURN p
        """
        
        with self.driver.session() as session:
            result = session.run(query, {'id': patient_id})
            if result.single():
                print(f"✓ Removed label :{label} from patient {patient_id}")
                return True
            return False
    
    # -------------------------------------------------------------------------
    # DELETE OPERATIONS
    # -------------------------------------------------------------------------
    
    def delete_patient(self, patient_id: str, detach: bool = True) -> bool:
        """
        Delete a patient node.
        
        Args:
            patient_id: The patient's ID
            detach: If True, also delete all relationships (DETACH DELETE)
                   If False, fails if node has relationships
            
        Returns:
            True if deleted, False if not found
        """
        if detach:
            query = """
            MATCH (p:Patient {id: $id})
            DETACH DELETE p
            RETURN count(p) AS deleted
            """
        else:
            query = """
            MATCH (p:Patient {id: $id})
            DELETE p
            RETURN count(p) AS deleted
            """
        
        with self.driver.session() as session:
            result = session.run(query, {'id': patient_id})
            deleted = result.single()['deleted'] > 0
            if deleted:
                print(f"✓ Deleted patient {patient_id}")
            return deleted
    
    def delete_all_by_label(self, label: str) -> int:
        """
        Delete all nodes with a specific label.
        
        WARNING: This is destructive! Use with caution.
        
        Args:
            label: The label of nodes to delete
            
        Returns:
            Number of nodes deleted
        """
        if not label.isalnum():
            raise ValueError("Label must be alphanumeric")
        
        query = f"""
        MATCH (n:{label})
        DETACH DELETE n
        RETURN count(n) AS deleted
        """
        
        with self.driver.session() as session:
            result = session.run(query)
            count = result.single()['deleted']
            print(f"✓ Deleted {count} nodes with label :{label}")
            return count


# =============================================================================
# DEMONSTRATION
# =============================================================================

def demonstrate_node_operations():
    """Demonstrate all node operations."""
    
    # Get connection parameters from environment
    uri = os.getenv('NEO4J_URI', 'bolt://localhost:7687')
    user = os.getenv('NEO4J_USER', 'neo4j')
    password = os.getenv('NEO4J_PASSWORD', 'password')
    
    # Use context manager for automatic cleanup
    with NodeManager(uri, user, password) as manager:
        
        print("\n" + "=" * 60)
        print("CREATING SAMPLE PATIENTS")
        print("=" * 60)
        
        # Create sample patients
        patients = [
            Patient(
                id="P001",
                name="John Doe",
                date_of_birth=date(1979, 3, 15),
                gender="M",
                blood_type="A+",
                email="john.doe@email.com",
                phone="555-0101",
                tags=["diabetic", "hypertension"]
            ),
            Patient(
                id="P002",
                name="Jane Smith",
                date_of_birth=date(1985, 7, 22),
                gender="F",
                blood_type="O-",
                email="jane.smith@email.com",
                phone="555-0102",
                tags=["asthma"]
            ),
            Patient(
                id="P003",
                name="Bob Wilson",
                date_of_birth=date(1960, 12, 1),
                gender="M",
                blood_type="B+",
                email="bob.wilson@email.com",
                phone="555-0103",
                tags=["cardiac", "diabetic"]
            ),
        ]
        
        for patient in patients:
            manager.create_patient(patient)
        
        print("\n" + "=" * 60)
        print("CREATING DISEASES")
        print("=" * 60)
        
        # Create diseases with ICD-11 codes
        diseases = [
            Disease(
                id="E11",
                name="Type 2 Diabetes Mellitus",
                category="Endocrine",
                description="A metabolic disorder characterized by high blood sugar",
                symptoms=["frequent urination", "increased thirst", "fatigue"],
                risk_factors=["obesity", "sedentary lifestyle", "family history"],
                icd10_code="E11",
                chronic=True
            ),
            Disease(
                id="I10",
                name="Essential Hypertension",
                category="Cardiovascular",
                description="Persistently elevated blood pressure",
                symptoms=["headache", "shortness of breath", "nosebleeds"],
                risk_factors=["age", "obesity", "high sodium diet", "stress"],
                icd10_code="I10",
                chronic=True
            ),
            Disease(
                id="J45",
                name="Asthma",
                category="Respiratory",
                description="Chronic inflammatory disease of the airways",
                symptoms=["wheezing", "coughing", "chest tightness"],
                risk_factors=["allergies", "family history", "air pollution"],
                icd10_code="J45",
                chronic=True
            ),
        ]
        
        for disease in diseases:
            manager.create_disease(disease)
        
        print("\n" + "=" * 60)
        print("CREATING MEDICATIONS")
        print("=" * 60)
        
        # Create medications with RxNorm codes
        medications = [
            Medication(
                id="6809",
                name="Metformin",
                brand_names=["Glucophage", "Fortamet", "Glumetza"],
                drug_class="Biguanide",
                dosage_forms=["tablet", "extended-release tablet", "oral solution"],
                contraindications=["kidney disease", "metabolic acidosis"],
                side_effects=["nausea", "diarrhea", "stomach upset"],
                requires_prescription=True
            ),
            Medication(
                id="29046",
                name="Lisinopril",
                brand_names=["Prinivil", "Zestril"],
                drug_class="ACE Inhibitor",
                dosage_forms=["tablet"],
                contraindications=["pregnancy", "angioedema history"],
                side_effects=["dry cough", "dizziness", "headache"],
                requires_prescription=True
            ),
        ]
        
        for medication in medications:
            manager.create_medication(medication)
        
        print("\n" + "=" * 60)
        print("SEARCHING PATIENTS")
        print("=" * 60)
        
        # Search examples
        print("\n1. Search by name:")
        results = manager.search_patients(name_contains="john")
        for p in results:
            print(f"   - {p['name']} ({p['id']})")
        
        print("\n2. Search male patients:")
        results = manager.search_patients(gender="M")
        for p in results:
            print(f"   - {p['name']} ({p['id']})")
        
        print("\n3. Search by tags (diabetic):")
        results = manager.search_patients(tags=["diabetic"])
        for p in results:
            print(f"   - {p['name']} ({p['id']}) - tags: {p['tags']}")
        
        print("\n" + "=" * 60)
        print("UPDATING PATIENT")
        print("=" * 60)
        
        # Update a patient
        manager.update_patient_properties("P001", {
            "phone": "555-9999",
            "address": "123 Main St, City, State 12345"
        })
        
        # Add a label
        manager.add_label("P001", "HighRisk")
        
        print("\n" + "=" * 60)
        print("NODE COUNTS")
        print("=" * 60)
        
        manager.count_nodes_by_label("Patient")
        manager.count_nodes_by_label("Disease")
        manager.count_nodes_by_label("Medication")
        
        print("\n✅ Node operations demonstration complete!")


if __name__ == "__main__":
    demonstrate_node_operations()
```

### Key Concepts Explained

**1. MERGE vs CREATE:**
- `CREATE` always creates a new node
- `MERGE` finds existing node or creates new one (upsert)
- Use `MERGE` with a unique property to prevent duplicates

**2. ON CREATE SET / ON MATCH SET:**
```cypher
MERGE (p:Patient {id: $id})
ON CREATE SET p.created_at = datetime()  // Only on new nodes
ON MATCH SET p.updated_at = datetime()   // Only on existing nodes
```

**3. Multiple Labels:**
```cypher
// Create node with multiple labels
CREATE (p:Patient:Person:Entity {name: "John"})

// Add label later
MATCH (p:Patient {id: "P001"})
SET p:HighRisk
```

---

# 4. Relationships and Patterns

## 4.1 Understanding Relationships

Relationships connect nodes and give meaning to data. They are the "edges" in a graph.

### Relationship Anatomy

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                          RELATIONSHIP ANATOMY                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│    ┌──────────────┐                                    ┌──────────────┐    │
│    │   Patient    │                                    │   Disease    │    │
│    │   (P001)     │                                    │   (E11)      │    │
│    │   "John"     │                                    │  "Diabetes"  │    │
│    └──────┬───────┘                                    └──────▲───────┘    │
│           │                                                   │            │
│           │        ┌─────────────────────────────┐            │            │
│           └────────│     RELATIONSHIP            │────────────┘            │
│                    │                             │                         │
│                    │  Type: DIAGNOSED_WITH       │                         │
│                    │  Direction: → (outgoing)    │                         │
│                    │                             │                         │
│                    │  Properties:                │                         │
│                    │    diagnosed_date: 2024-01  │                         │
│                    │    diagnosed_by: "Dr.Smith" │                         │
│                    │    severity: "moderate"     │                         │
│                    │    status: "active"         │                         │
│                    │    icd_code: "E11.9"        │                         │
│                    └─────────────────────────────┘                         │
│                                                                             │
│    Start Node ──────────────────────────────────────────────► End Node     │
│    (Source)                                                    (Target)    │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

### Relationship Direction

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                      RELATIONSHIP DIRECTIONS                                │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  Directed (explicit direction):                                             │
│  ┌────────┐                          ┌────────┐                            │
│  │ Patient │───── DIAGNOSED_WITH ───▶│Disease │                            │
│  └────────┘                          └────────┘                            │
│                                                                             │
│  Query: MATCH (p:Patient)-[:DIAGNOSED_WITH]->(d:Disease)                   │
│         (Only finds Patient→Disease, not Disease→Patient)                  │
│                                                                             │
│  ─────────────────────────────────────────────────────────────────────     │
│                                                                             │
│  Undirected (match either direction):                                       │
│  ┌────────┐                          ┌────────┐                            │
│  │ Person │◀─────── KNOWS ──────────▶│ Person │                            │
│  └────────┘                          └────────┘                            │
│                                                                             │
│  Query: MATCH (a:Person)-[:KNOWS]-(b:Person)                               │
│         (Finds both A→B and B→A)                                           │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

## 4.2 Healthcare Domain Relationships

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                    HEALTHCARE KNOWLEDGE GRAPH                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│                         ┌──────────────┐                                    │
│                         │   Hospital   │                                    │
│                         │  "City Gen"  │                                    │
│                         └──────┬───────┘                                    │
│                                │                                            │
│                          WORKS_AT                                           │
│                                │                                            │
│     ┌──────────────┐    ┌──────▼───────┐    ┌──────────────┐              │
│     │  Insurance   │    │    Doctor    │    │  Department  │              │
│     │  "BlueCross" │    │  "Dr.Smith"  │    │  "Cardiology"│              │
│     └──────┬───────┘    └──────┬───────┘    └──────────────┘              │
│            │                   │                                            │
│       COVERED_BY          TREATS                                            │
│            │                   │                                            │
│     ┌──────▼───────┐    ┌──────▼───────┐                                   │
│     │   Patient    │────│   Patient    │                                   │
│     │   "John"     │    │   "Jane"     │                                   │
│     └──────┬───────┘    └──────┬───────┘                                   │
│            │                   │                                            │
│    ┌───────┴───────┬───────────┼───────────┬───────────┐                   │
│    │               │           │           │           │                   │
│    ▼               ▼           ▼           ▼           ▼                   │
│ DIAGNOSED     PRESCRIBED   ALLERGIC    VISITED    EMERGENCY               │
│  _WITH          _WITH       _TO        _ON         _CONTACT               │
│    │               │           │           │           │                   │
│    ▼               ▼           ▼           ▼           ▼                   │
│ ┌──────┐     ┌──────────┐  ┌──────┐   ┌──────┐   ┌──────┐                 │
│ │Disease│     │Medication│  │Allergy│   │ Visit│   │Person│                 │
│ │"E11"  │     │"Metformin"│  │"Penic" │   │ Date │   │"Jane"│                 │
│ └──────┘     └──────────┘  └──────┘   └──────┘   └──────┘                 │
│                   │                                                         │
│              TREATS                                                         │
│                   │                                                         │
│                   ▼                                                         │
│              ┌──────────┐                                                   │
│              │  Disease │                                                   │
│              │  "E11"   │                                                   │
│              └──────────┘                                                   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

## 4.3 Creating Relationships with Python

### File: `src/03_relationships.py`

```python
#!/usr/bin/env python3
"""
03_relationships.py - Relationship Operations in Neo4j

This module demonstrates:
- Creating relationships with properties
- Relationship types and patterns
- Querying relationships
- Updating and deleting relationships
- Healthcare domain relationships

Run: python src/03_relationships.py
"""

from dataclasses import dataclass, field, asdict
from datetime import date, datetime
from typing import Optional, List, Dict, Any, Tuple
from enum import Enum
from neo4j import GraphDatabase
import os
from dotenv import load_dotenv

load_dotenv()


# =============================================================================
# RELATIONSHIP TYPES
# =============================================================================

class RelationshipType(Enum):
    """Enumeration of relationship types in healthcare domain."""
    
    # Patient relationships
    DIAGNOSED_WITH = "DIAGNOSED_WITH"       # Patient -> Disease
    TAKES = "TAKES"                         # Patient -> Medication (prescription)
    ALLERGIC_TO = "ALLERGIC_TO"             # Patient -> Medication/Substance
    TREATED_BY = "TREATED_BY"               # Patient -> Doctor
    VISITED = "VISITED"                     # Patient -> Hospital
    HAS_LAB_RESULT = "HAS_LAB_RESULT"       # Patient -> LabResult
    EMERGENCY_CONTACT = "EMERGENCY_CONTACT"  # Patient -> Person
    
    # Medication relationships
    TREATS = "TREATS"                       # Medication -> Disease
    INTERACTS_WITH = "INTERACTS_WITH"       # Medication -> Medication
    CONTRAINDICATED_FOR = "CONTRAINDICATED" # Medication -> Condition
    
    # Doctor relationships
    SPECIALIZES_IN = "SPECIALIZES_IN"       # Doctor -> Specialty
    WORKS_AT = "WORKS_AT"                   # Doctor -> Hospital
    MEMBER_OF = "MEMBER_OF"                 # Doctor -> Department
    
    # Hierarchical relationships
    PART_OF = "PART_OF"                     # Entity -> Parent Entity
    SUBCLASS_OF = "SUBCLASS_OF"             # Type -> Parent Type


# =============================================================================
# RELATIONSHIP DATA MODELS
# =============================================================================

@dataclass
class Diagnosis:
    """Properties for DIAGNOSED_WITH relationship."""
    
    diagnosed_date: date
    diagnosed_by: str                      # Doctor name/ID
    status: str = "active"                 # active, resolved, chronic
    severity: str = "moderate"             # mild, moderate, severe
    icd_code: Optional[str] = None         # Specific ICD code
    notes: Optional[str] = None
    confirmed: bool = True                 # Confirmed vs suspected
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            'diagnosed_date': self.diagnosed_date.isoformat(),
            'diagnosed_by': self.diagnosed_by,
            'status': self.status,
            'severity': self.severity,
            'icd_code': self.icd_code,
            'notes': self.notes,
            'confirmed': self.confirmed
        }


@dataclass
class Prescription:
    """Properties for TAKES relationship (medication prescription)."""
    
    prescribed_date: date
    prescribed_by: str
    dosage: str                            # e.g., "500mg"
    frequency: str                         # e.g., "twice daily"
    duration_days: Optional[int] = None    # None = ongoing
    refills_remaining: int = 0
    status: str = "active"                 # active, completed, discontinued
    instructions: Optional[str] = None     # Special instructions
    start_date: Optional[date] = None
    end_date: Optional[date] = None
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            'prescribed_date': self.prescribed_date.isoformat(),
            'prescribed_by': self.prescribed_by,
            'dosage': self.dosage,
            'frequency': self.frequency,
            'duration_days': self.duration_days,
            'refills_remaining': self.refills_remaining,
            'status': self.status,
            'instructions': self.instructions,
            'start_date': self.start_date.isoformat() if self.start_date else None,
            'end_date': self.end_date.isoformat() if self.end_date else None
        }


@dataclass
class DrugInteraction:
    """Properties for INTERACTS_WITH relationship."""
    
    severity: str                          # mild, moderate, severe, contraindicated
    description: str
    effect: str                            # What happens
    recommendation: str                    # What to do
    evidence_level: str = "established"    # established, theoretical, case_reports
    
    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)


# =============================================================================
# RELATIONSHIP MANAGER CLASS
# =============================================================================

class RelationshipManager:
    """
    Manages relationship CRUD operations in Neo4j.
    
    This class provides methods for:
    - Creating relationships with properties
    - Querying relationships and patterns
    - Updating relationship properties
    - Deleting relationships
    """
    
    def __init__(self, uri: str, user: str, password: str):
        """Initialize connection to Neo4j."""
        self.driver = GraphDatabase.driver(uri, auth=(user, password))
        print(f"✓ Connected to Neo4j at {uri}")
    
    def close(self):
        """Close the driver connection."""
        self.driver.close()
        print("✓ Connection closed")
    
    def __enter__(self):
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
    
    # -------------------------------------------------------------------------
    # CREATE RELATIONSHIPS
    # -------------------------------------------------------------------------
    
    def create_diagnosis(
        self,
        patient_id: str,
        disease_id: str,
        diagnosis: Diagnosis
    ) -> Dict[str, Any]:
        """
        Create a DIAGNOSED_WITH relationship between patient and disease.
        
        Args:
            patient_id: Patient's ID
            disease_id: Disease's ICD code
            diagnosis: Diagnosis details
            
        Returns:
            Relationship properties
        """
        query = """
        MATCH (p:Patient {id: $patient_id})
        MATCH (d:Disease {id: $disease_id})
        MERGE (p)-[r:DIAGNOSED_WITH {diagnosed_date: date($diagnosed_date)}]->(d)
        ON CREATE SET
            r.diagnosed_by = $diagnosed_by,
            r.status = $status,
            r.severity = $severity,
            r.icd_code = $icd_code,
            r.notes = $notes,
            r.confirmed = $confirmed,
            r.created_at = datetime()
        ON MATCH SET
            r.diagnosed_by = $diagnosed_by,
            r.status = $status,
            r.severity = $severity,
            r.icd_code = $icd_code,
            r.notes = $notes,
            r.confirmed = $confirmed,
            r.updated_at = datetime()
        RETURN r {.*} AS relationship,
               p.name AS patient_name,
               d.name AS disease_name
        """
        
        params = {
            'patient_id': patient_id,
            'disease_id': disease_id,
            **diagnosis.to_dict()
        }
        
        with self.driver.session() as session:
            result = session.run(query, params)
            record = result.single()
            if record:
                print(f"✓ Created diagnosis: {record['patient_name']} -> "
                      f"{record['disease_name']} ({diagnosis.severity})")
                return record['relationship']
            return None
    
    def create_prescription(
        self,
        patient_id: str,
        medication_id: str,
        prescription: Prescription
    ) -> Dict[str, Any]:
        """
        Create a TAKES relationship between patient and medication.
        
        Args:
            patient_id: Patient's ID
            medication_id: Medication's RxNorm code
            prescription: Prescription details
            
        Returns:
            Relationship properties
        """
        query = """
        MATCH (p:Patient {id: $patient_id})
        MATCH (m:Medication {id: $medication_id})
        CREATE (p)-[r:TAKES {
            prescribed_date: date($prescribed_date),
            prescribed_by: $prescribed_by,
            dosage: $dosage,
            frequency: $frequency,
            duration_days: $duration_days,
            refills_remaining: $refills_remaining,
            status: $status,
            instructions: $instructions,
            start_date: CASE WHEN $start_date IS NOT NULL 
                        THEN date($start_date) ELSE null END,
            end_date: CASE WHEN $end_date IS NOT NULL 
                      THEN date($end_date) ELSE null END,
            created_at: datetime()
        }]->(m)
        RETURN r {.*} AS relationship,
               p.name AS patient_name,
               m.name AS medication_name
        """
        
        params = {
            'patient_id': patient_id,
            'medication_id': medication_id,
            **prescription.to_dict()
        }
        
        with self.driver.session() as session:
            result = session.run(query, params)
            record = result.single()
            if record:
                print(f"✓ Created prescription: {record['patient_name']} takes "
                      f"{record['medication_name']} ({prescription.dosage})")
                return record['relationship']
            return None
    
    def create_treatment_relationship(
        self,
        medication_id: str,
        disease_id: str,
        effectiveness: str = "high",
        first_line: bool = True
    ) -> Dict[str, Any]:
        """
        Create a TREATS relationship between medication and disease.
        
        Args:
            medication_id: Medication's RxNorm code
            disease_id: Disease's ICD code
            effectiveness: How effective (high, moderate, low)
            first_line: Is it a first-line treatment?
            
        Returns:
            Relationship properties
        """
        query = """
        MATCH (m:Medication {id: $medication_id})
        MATCH (d:Disease {id: $disease_id})
        MERGE (m)-[r:TREATS]->(d)
        ON CREATE SET
            r.effectiveness = $effectiveness,
            r.first_line = $first_line,
            r.created_at = datetime()
        ON MATCH SET
            r.effectiveness = $effectiveness,
            r.first_line = $first_line,
            r.updated_at = datetime()
        RETURN r {.*} AS relationship,
               m.name AS medication_name,
               d.name AS disease_name
        """
        
        params = {
            'medication_id': medication_id,
            'disease_id': disease_id,
            'effectiveness': effectiveness,
            'first_line': first_line
        }
        
        with self.driver.session() as session:
            result = session.run(query, params)
            record = result.single()
            if record:
                print(f"✓ Created treatment: {record['medication_name']} treats "
                      f"{record['disease_name']} (effectiveness: {effectiveness})")
                return record['relationship']
            return None
    
    def create_drug_interaction(
        self,
        medication1_id: str,
        medication2_id: str,
        interaction: DrugInteraction
    ) -> Dict[str, Any]:
        """
        Create an INTERACTS_WITH relationship between two medications.
        
        This is bidirectional conceptually but stored as directed.
        
        Args:
            medication1_id: First medication's ID
            medication2_id: Second medication's ID
            interaction: Interaction details
            
        Returns:
            Relationship properties
        """
        query = """
        MATCH (m1:Medication {id: $medication1_id})
        MATCH (m2:Medication {id: $medication2_id})
        MERGE (m1)-[r:INTERACTS_WITH]->(m2)
        ON CREATE SET
            r.severity = $severity,
            r.description = $description,
            r.effect = $effect,
            r.recommendation = $recommendation,
            r.evidence_level = $evidence_level,
            r.created_at = datetime()
        RETURN r {.*} AS relationship,
               m1.name AS med1_name,
               m2.name AS med2_name
        """
        
        params = {
            'medication1_id': medication1_id,
            'medication2_id': medication2_id,
            **interaction.to_dict()
        }
        
        with self.driver.session() as session:
            result = session.run(query, params)
            record = result.single()
            if record:
                print(f"⚠️ Drug interaction: {record['med1_name']} <-> "
                      f"{record['med2_name']} ({interaction.severity})")
                return record['relationship']
            return None
    
    # -------------------------------------------------------------------------
    # QUERY RELATIONSHIPS
    # -------------------------------------------------------------------------
    
    def get_patient_diagnoses(
        self,
        patient_id: str,
        status: Optional[str] = None
    ) -> List[Dict[str, Any]]:
        """
        Get all diagnoses for a patient.
        
        Args:
            patient_id: Patient's ID
            status: Filter by status (active, resolved, chronic)
            
        Returns:
            List of diagnosis dictionaries
        """
        if status:
            query = """
            MATCH (p:Patient {id: $patient_id})-[r:DIAGNOSED_WITH]->(d:Disease)
            WHERE r.status = $status
            RETURN d.name AS disease,
                   d.id AS disease_id,
                   r {.*} AS diagnosis
            ORDER BY r.diagnosed_date DESC
            """
            params = {'patient_id': patient_id, 'status': status}
        else:
            query = """
            MATCH (p:Patient {id: $patient_id})-[r:DIAGNOSED_WITH]->(d:Disease)
            RETURN d.name AS disease,
                   d.id AS disease_id,
                   r {.*} AS diagnosis
            ORDER BY r.diagnosed_date DESC
            """
            params = {'patient_id': patient_id}
        
        with self.driver.session() as session:
            result = session.run(query, params)
            diagnoses = [
                {
                    'disease': record['disease'],
                    'disease_id': record['disease_id'],
                    **record['diagnosis']
                }
                for record in result
            ]
            print(f"✓ Found {len(diagnoses)} diagnoses for patient {patient_id}")
            return diagnoses
    
    def get_patient_medications(
        self,
        patient_id: str,
        active_only: bool = True
    ) -> List[Dict[str, Any]]:
        """
        Get all medications for a patient.
        
        Args:
            patient_id: Patient's ID
            active_only: Only return active prescriptions
            
        Returns:
            List of medication dictionaries
        """
        if active_only:
            query = """
            MATCH (p:Patient {id: $patient_id})-[r:TAKES]->(m:Medication)
            WHERE r.status = 'active'
            RETURN m.name AS medication,
                   m.id AS medication_id,
                   r {.*} AS prescription
            ORDER BY r.prescribed_date DESC
            """
        else:
            query = """
            MATCH (p:Patient {id: $patient_id})-[r:TAKES]->(m:Medication)
            RETURN m.name AS medication,
                   m.id AS medication_id,
                   r {.*} AS prescription
            ORDER BY r.prescribed_date DESC
            """
        
        with self.driver.session() as session:
            result = session.run(query, {'patient_id': patient_id})
            medications = [
                {
                    'medication': record['medication'],
                    'medication_id': record['medication_id'],
                    **record['prescription']
                }
                for record in result
            ]
            print(f"✓ Found {len(medications)} medications for patient {patient_id}")
            return medications
    
    def get_disease_treatments(self, disease_id: str) -> List[Dict[str, Any]]:
        """
        Get all medications that treat a disease.
        
        Args:
            disease_id: Disease's ICD code
            
        Returns:
            List of treatment dictionaries
        """
        query = """
        MATCH (m:Medication)-[r:TREATS]->(d:Disease {id: $disease_id})
        RETURN m.name AS medication,
               m.id AS medication_id,
               m.drug_class AS drug_class,
               r.effectiveness AS effectiveness,
               r.first_line AS first_line
        ORDER BY r.first_line DESC, r.effectiveness
        """
        
        with self.driver.session() as session:
            result = session.run(query, {'disease_id': disease_id})
            treatments = [dict(record) for record in result]
            print(f"✓ Found {len(treatments)} treatments for disease {disease_id}")
            return treatments
    
    def check_drug_interactions(
        self,
        patient_id: str
    ) -> List[Dict[str, Any]]:
        """
        Check for drug interactions among patient's current medications.
        
        This is a critical safety check!
        
        Args:
            patient_id: Patient's ID
            
        Returns:
            List of interaction warnings
        """
        query = """
        MATCH (p:Patient {id: $patient_id})-[r1:TAKES]->(m1:Medication)
        WHERE r1.status = 'active'
        MATCH (p)-[r2:TAKES]->(m2:Medication)
        WHERE r2.status = 'active' AND id(m1) < id(m2)
        MATCH (m1)-[i:INTERACTS_WITH]-(m2)
        RETURN m1.name AS medication1,
               m2.name AS medication2,
               i.severity AS severity,
               i.description AS description,
               i.effect AS effect,
               i.recommendation AS recommendation
        ORDER BY 
            CASE i.severity 
                WHEN 'contraindicated' THEN 1
                WHEN 'severe' THEN 2
                WHEN 'moderate' THEN 3
                WHEN 'mild' THEN 4
            END
        """
        
        with self.driver.session() as session:
            result = session.run(query, {'patient_id': patient_id})
            interactions = [dict(record) for record in result]
            
            if interactions:
                print(f"⚠️ Found {len(interactions)} drug interactions!")
                for i in interactions:
                    print(f"   {i['severity'].upper()}: "
                          f"{i['medication1']} + {i['medication2']}")
            else:
                print("✓ No drug interactions found")
            
            return interactions
    
    def get_treatment_path(
        self,
        patient_id: str,
        disease_id: str
    ) -> List[Dict[str, Any]]:
        """
        Get the treatment path: Patient -> Disease -> Medications.
        
        Shows how a patient is being treated for a specific condition.
        
        Args:
            patient_id: Patient's ID
            disease_id: Disease's ICD code
            
        Returns:
            Treatment path details
        """
        query = """
        MATCH (p:Patient {id: $patient_id})-[diag:DIAGNOSED_WITH]->(d:Disease {id: $disease_id})
        OPTIONAL MATCH (m:Medication)-[treats:TREATS]->(d)
        OPTIONAL MATCH (p)-[takes:TAKES]->(m)
        WHERE takes.status = 'active'
        RETURN p.name AS patient,
               d.name AS disease,
               diag.severity AS severity,
               diag.status AS diagnosis_status,
               collect(DISTINCT {
                   medication: m.name,
                   dosage: takes.dosage,
                   frequency: takes.frequency,
                   effectiveness: treats.effectiveness
               }) AS treatments
        """
        
        with self.driver.session() as session:
            result = session.run(query, {
                'patient_id': patient_id,
                'disease_id': disease_id
            })
            record = result.single()
            if record:
                return dict(record)
            return None
    
    # -------------------------------------------------------------------------
    # UPDATE RELATIONSHIPS
    # -------------------------------------------------------------------------
    
    def update_diagnosis_status(
        self,
        patient_id: str,
        disease_id: str,
        new_status: str,
        notes: Optional[str] = None
    ) -> bool:
        """
        Update the status of a diagnosis.
        
        Args:
            patient_id: Patient's ID
            disease_id: Disease's ID
            new_status: New status (active, resolved, chronic)
            notes: Optional notes about the change
            
        Returns:
            True if updated, False if not found
        """
        query = """
        MATCH (p:Patient {id: $patient_id})-[r:DIAGNOSED_WITH]->(d:Disease {id: $disease_id})
        SET r.status = $new_status,
            r.notes = COALESCE($notes, r.notes),
            r.updated_at = datetime()
        RETURN r
        """
        
        with self.driver.session() as session:
            result = session.run(query, {
                'patient_id': patient_id,
                'disease_id': disease_id,
                'new_status': new_status,
                'notes': notes
            })
            if result.single():
                print(f"✓ Updated diagnosis status to '{new_status}'")
                return True
            return False
    
    def refill_prescription(
        self,
        patient_id: str,
        medication_id: str,
        new_refills: int
    ) -> bool:
        """
        Add refills to a prescription.
        
        Args:
            patient_id: Patient's ID
            medication_id: Medication's ID
            new_refills: Number of refills to add
            
        Returns:
            True if updated, False if not found
        """
        query = """
        MATCH (p:Patient {id: $patient_id})-[r:TAKES]->(m:Medication {id: $medication_id})
        WHERE r.status = 'active'
        SET r.refills_remaining = r.refills_remaining + $new_refills,
            r.updated_at = datetime()
        RETURN r.refills_remaining AS total_refills
        """
        
        with self.driver.session() as session:
            result = session.run(query, {
                'patient_id': patient_id,
                'medication_id': medication_id,
                'new_refills': new_refills
            })
            record = result.single()
            if record:
                print(f"✓ Refills updated: {record['total_refills']} remaining")
                return True
            return False
    
    def discontinue_medication(
        self,
        patient_id: str,
        medication_id: str,
        reason: str
    ) -> bool:
        """
        Discontinue a medication (soft delete).
        
        Args:
            patient_id: Patient's ID
            medication_id: Medication's ID
            reason: Reason for discontinuation
            
        Returns:
            True if discontinued, False if not found
        """
        query = """
        MATCH (p:Patient {id: $patient_id})-[r:TAKES]->(m:Medication {id: $medication_id})
        WHERE r.status = 'active'
        SET r.status = 'discontinued',
            r.end_date = date(),
            r.discontinue_reason = $reason,
            r.updated_at = datetime()
        RETURN m.name AS medication
        """
        
        with self.driver.session() as session:
            result = session.run(query, {
                'patient_id': patient_id,
                'medication_id': medication_id,
                'reason': reason
            })
            record = result.single()
            if record:
                print(f"✓ Discontinued: {record['medication']} - {reason}")
                return True
            return False
    
    # -------------------------------------------------------------------------
    # DELETE RELATIONSHIPS
    # -------------------------------------------------------------------------
    
    def delete_relationship(
        self,
        start_id: str,
        start_label: str,
        end_id: str,
        end_label: str,
        rel_type: str
    ) -> int:
        """
        Delete a specific relationship.
        
        Args:
            start_id: Start node's ID
            start_label: Start node's label
            end_id: End node's ID
            end_label: End node's label
            rel_type: Relationship type
            
        Returns:
            Number of relationships deleted
        """
        # Validate inputs to prevent injection
        if not all(s.isalnum() or s == '_' for s in [start_label, end_label, rel_type]):
            raise ValueError("Labels and type must be alphanumeric")
        
        query = f"""
        MATCH (a:{start_label} {{id: $start_id}})-[r:{rel_type}]->(b:{end_label} {{id: $end_id}})
        DELETE r
        RETURN count(r) AS deleted
        """
        
        with self.driver.session() as session:
            result = session.run(query, {
                'start_id': start_id,
                'end_id': end_id
            })
            count = result.single()['deleted']
            print(f"✓ Deleted {count} relationship(s)")
            return count


# =============================================================================
# DEMONSTRATION
# =============================================================================

def demonstrate_relationships():
    """Demonstrate relationship operations."""
    
    uri = os.getenv('NEO4J_URI', 'bolt://localhost:7687')
    user = os.getenv('NEO4J_USER', 'neo4j')
    password = os.getenv('NEO4J_PASSWORD', 'password')
    
    with RelationshipManager(uri, user, password) as manager:
        
        print("\n" + "=" * 60)
        print("CREATING DIAGNOSES")
        print("=" * 60)
        
        # Create diagnosis
        diagnosis = Diagnosis(
            diagnosed_date=date(2024, 1, 15),
            diagnosed_by="Dr. Smith",
            status="active",
            severity="moderate",
            icd_code="E11.9",
            notes="Patient showing early signs, starting treatment",
            confirmed=True
        )
        manager.create_diagnosis("P001", "E11", diagnosis)
        
        # Another diagnosis
        hypertension_dx = Diagnosis(
            diagnosed_date=date(2023, 6, 20),
            diagnosed_by="Dr. Johnson",
            status="active",
            severity="mild",
            icd_code="I10"
        )
        manager.create_diagnosis("P001", "I10", hypertension_dx)
        
        print("\n" + "=" * 60)
        print("CREATING PRESCRIPTIONS")
        print("=" * 60)
        
        # Create prescription
        prescription = Prescription(
            prescribed_date=date(2024, 1, 15),
            prescribed_by="Dr. Smith",
            dosage="500mg",
            frequency="twice daily with meals",
            duration_days=90,
            refills_remaining=3,
            status="active",
            instructions="Take with food to reduce GI side effects",
            start_date=date(2024, 1, 16)
        )
        manager.create_prescription("P001", "6809", prescription)
        
        # Second prescription
        lisinopril_rx = Prescription(
            prescribed_date=date(2023, 6, 20),
            prescribed_by="Dr. Johnson",
            dosage="10mg",
            frequency="once daily in morning",
            refills_remaining=5,
            status="active"
        )
        manager.create_prescription("P001", "29046", lisinopril_rx)
        
        print("\n" + "=" * 60)
        print("CREATING TREATMENT RELATIONSHIPS")
        print("=" * 60)
        
        # Medication treats disease
        manager.create_treatment_relationship("6809", "E11", "high", True)
        manager.create_treatment_relationship("29046", "I10", "high", True)
        
        print("\n" + "=" * 60)
        print("QUERYING PATIENT DATA")
        print("=" * 60)
        
        # Get diagnoses
        print("\nPatient P001's diagnoses:")
        diagnoses = manager.get_patient_diagnoses("P001")
        for dx in diagnoses:
            print(f"  - {dx['disease']}: {dx['severity']} ({dx['status']})")
        
        # Get medications
        print("\nPatient P001's medications:")
        meds = manager.get_patient_medications("P001")
        for med in meds:
            print(f"  - {med['medication']}: {med['dosage']} {med['frequency']}")
        
        # Check drug interactions
        print("\nDrug interaction check:")
        manager.check_drug_interactions("P001")
        
        # Get treatment path
        print("\nTreatment path for Diabetes:")
        path = manager.get_treatment_path("P001", "E11")
        if path:
            print(f"  Patient: {path['patient']}")
            print(f"  Disease: {path['disease']} ({path['severity']})")
            for t in path['treatments']:
                if t['medication']:
                    print(f"  - Taking: {t['medication']} {t['dosage']}")
        
        print("\n✅ Relationship operations demonstration complete!")


if __name__ == "__main__":
    demonstrate_relationships()
```

---

# 5. Basic CRUD Operations

## 5.1 Complete CRUD Overview

CRUD operations form the foundation of database interaction:

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                          CRUD OPERATIONS                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐    │
│  │   CREATE    │   │    READ     │   │   UPDATE    │   │   DELETE    │    │
│  │             │   │             │   │             │   │             │    │
│  │  CREATE     │   │  MATCH      │   │  SET        │   │  DELETE     │    │
│  │  MERGE      │   │  RETURN     │   │  REMOVE     │   │  DETACH     │    │
│  │             │   │  WHERE      │   │             │   │  DELETE     │    │
│  └─────────────┘   └─────────────┘   └─────────────┘   └─────────────┘    │
│                                                                             │
│  Examples:                                                                  │
│                                                                             │
│  CREATE (p:Patient {id: "P001", name: "John"})                             │
│                                                                             │
│  MATCH (p:Patient {id: "P001"}) RETURN p                                   │
│                                                                             │
│  MATCH (p:Patient {id: "P001"}) SET p.phone = "555-1234"                   │
│                                                                             │
│  MATCH (p:Patient {id: "P001"}) DETACH DELETE p                            │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

## 5.2 Cypher CRUD Examples

### CREATE Operations

```cypher
// 1. Create a single node
CREATE (p:Patient {
    id: "P001",
    name: "John Doe",
    date_of_birth: date("1979-03-15"),
    gender: "M",
    active: true
})
RETURN p

// 2. Create multiple nodes
CREATE (p1:Patient {id: "P002", name: "Jane Smith"}),
       (p2:Patient {id: "P003", name: "Bob Wilson"})
RETURN p1, p2

// 3. Create with relationship
CREATE (p:Patient {id: "P004", name: "Alice"})-[:DIAGNOSED_WITH {
    diagnosed_date: date(),
    severity: "mild"
}]->(d:Disease {id: "J45", name: "Asthma"})
RETURN p, d

// 4. MERGE - create if not exists
MERGE (p:Patient {id: "P001"})
ON CREATE SET p.name = "John Doe", p.created_at = datetime()
ON MATCH SET p.updated_at = datetime()
RETURN p
```

### READ Operations

```cypher
// 1. Read all patients
MATCH (p:Patient)
RETURN p

// 2. Read with filter
MATCH (p:Patient)
WHERE p.gender = "M" AND p.active = true
RETURN p.name, p.id

// 3. Read with relationship
MATCH (p:Patient)-[r:DIAGNOSED_WITH]->(d:Disease)
RETURN p.name AS patient, d.name AS disease, r.severity

// 4. Read with aggregation
MATCH (p:Patient)-[:DIAGNOSED_WITH]->(d:Disease)
RETURN d.name AS disease, count(p) AS patient_count
ORDER BY patient_count DESC

// 5. Read with path
MATCH path = (p:Patient {id: "P001"})-[:DIAGNOSED_WITH|TAKES*1..3]-(n)
RETURN path
```

### UPDATE Operations

```cypher
// 1. Update properties
MATCH (p:Patient {id: "P001"})
SET p.phone = "555-9999",
    p.email = "john.new@email.com",
    p.updated_at = datetime()
RETURN p

// 2. Add new property
MATCH (p:Patient {id: "P001"})
SET p.insurance_id = "INS123456"
RETURN p

// 3. Remove property
MATCH (p:Patient {id: "P001"})
REMOVE p.temporary_field
RETURN p

// 4. Add label
MATCH (p:Patient {id: "P001"})
SET p:HighRisk
RETURN labels(p)

// 5. Update relationship property
MATCH (p:Patient {id: "P001"})-[r:DIAGNOSED_WITH]->(d:Disease {id: "E11"})
SET r.status = "managed",
    r.last_review = date()
RETURN r
```

### DELETE Operations

```cypher
// 1. Delete node (must have no relationships)
MATCH (p:Patient {id: "TEMP001"})
DELETE p

// 2. Detach delete (delete node and all relationships)
MATCH (p:Patient {id: "P005"})
DETACH DELETE p

// 3. Delete relationship only
MATCH (p:Patient {id: "P001"})-[r:TAKES]->(m:Medication {id: "6809"})
DELETE r

// 4. Delete all nodes of a label
MATCH (t:TempNode)
DETACH DELETE t

// 5. Conditional delete
MATCH (p:Patient)
WHERE p.active = false AND p.last_visit < date() - duration({months: 12})
DETACH DELETE p
```

## 5.3 Python CRUD Implementation

### File: `src/04_basic_queries.py`

```python
#!/usr/bin/env python3
"""
04_basic_queries.py - Basic Cypher Queries with Python

This module demonstrates:
- All CRUD operations
- Parameterized queries
- Result handling
- Error handling
- Transaction patterns

Run: python src/04_basic_queries.py
"""

from typing import Optional, List, Dict, Any, Tuple
from neo4j import GraphDatabase
from neo4j.exceptions import Neo4jError, ConstraintError
import os
from dotenv import load_dotenv
from datetime import date, datetime

load_dotenv()


class QueryExecutor:
    """
    Executes Cypher queries against Neo4j.
    
    Demonstrates proper patterns for:
    - Connection management
    - Query execution
    - Result handling
    - Error handling
    """
    
    def __init__(self, uri: str, user: str, password: str):
        """Initialize connection."""
        self.driver = GraphDatabase.driver(uri, auth=(user, password))
        self._verify_connection()
    
    def _verify_connection(self):
        """Verify the connection works."""
        with self.driver.session() as session:
            result = session.run("RETURN 1 AS test")
            if result.single()['test'] != 1:
                raise ConnectionError("Failed to connect to Neo4j")
        print("✓ Connected to Neo4j")
    
    def close(self):
        """Close the driver."""
        self.driver.close()
    
    def __enter__(self):
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
    
    # =========================================================================
    # CREATE OPERATIONS
    # =========================================================================
    
    def create_node(
        self,
        label: str,
        properties: Dict[str, Any]
    ) -> Optional[Dict[str, Any]]:
        """
        Create a single node with given label and properties.
        
        Args:
            label: Node label (e.g., "Patient")
            properties: Dictionary of properties
            
        Returns:
            Created node properties or None on failure
        """
        # Validate label
        if not label.isidentifier():
            raise ValueError(f"Invalid label: {label}")
        
        # Build property string for Cypher
        prop_keys = list(properties.keys())
        prop_string = ", ".join([f"{k}: ${k}" for k in prop_keys])
        
        query = f"""
        CREATE (n:{label} {{{prop_string}}})
        RETURN n {{.*}} AS node
        """
        
        try:
            with self.driver.session() as session:
                result = session.run(query, properties)
                record = result.single()
                if record:
                    print(f"✓ Created {label} node")
                    return record['node']
                return None
        except ConstraintError as e:
            print(f"✗ Constraint violation: {e}")
            return None
        except Neo4jError as e:
            print(f"✗ Neo4j error: {e}")
            return None
    
    def merge_node(
        self,
        label: str,
        match_properties: Dict[str, Any],
        create_properties: Optional[Dict[str, Any]] = None,
        update_properties: Optional[Dict[str, Any]] = None
    ) -> Dict[str, Any]:
        """
        Merge a node - create if not exists, update if exists.
        
        Args:
            label: Node label
            match_properties: Properties to match on (unique identifiers)
            create_properties: Properties to set only on create
            update_properties: Properties to set on match (update)
            
        Returns:
            Node properties
        """
        if not label.isidentifier():
            raise ValueError(f"Invalid label: {label}")
        
        # Build MERGE clause
        match_str = ", ".join([f"{k}: $match_{k}" for k in match_properties])
        
        # Build ON CREATE SET clause
        create_sets = []
        if create_properties:
            create_sets = [f"n.{k} = $create_{k}" for k in create_properties]
        
        # Build ON MATCH SET clause
        update_sets = []
        if update_properties:
            update_sets = [f"n.{k} = $update_{k}" for k in update_properties]
        
        query = f"MERGE (n:{label} {{{match_str}}})"
        if create_sets:
            query += f"\nON CREATE SET {', '.join(create_sets)}"
        if update_sets:
            query += f"\nON MATCH SET {', '.join(update_sets)}"
        query += "\nRETURN n {.*} AS node"
        
        # Build parameters
        params = {f"match_{k}": v for k, v in match_properties.items()}
        if create_properties:
            params.update({f"create_{k}": v for k, v in create_properties.items()})
        if update_properties:
            params.update({f"update_{k}": v for k, v in update_properties.items()})
        
        with self.driver.session() as session:
            result = session.run(query, params)
            record = result.single()
            print(f"✓ Merged {label} node")
            return record['node']
    
    def create_relationship(
        self,
        start_label: str,
        start_match: Dict[str, Any],
        end_label: str,
        end_match: Dict[str, Any],
        rel_type: str,
        rel_properties: Optional[Dict[str, Any]] = None
    ) -> Optional[Dict[str, Any]]:
        """
        Create a relationship between two existing nodes.
        
        Args:
            start_label: Start node label
            start_match: Properties to match start node
            end_label: End node label
            end_match: Properties to match end node
            rel_type: Relationship type
            rel_properties: Relationship properties
            
        Returns:
            Relationship properties or None
        """
        # Validate inputs
        for val in [start_label, end_label, rel_type]:
            if not val.isidentifier() and not val.isupper():
                raise ValueError(f"Invalid identifier: {val}")
        
        # Build match clauses
        start_str = ", ".join([f"{k}: $start_{k}" for k in start_match])
        end_str = ", ".join([f"{k}: $end_{k}" for k in end_match])
        
        # Build relationship properties
        rel_props = ""
        if rel_properties:
            rel_str = ", ".join([f"{k}: $rel_{k}" for k in rel_properties])
            rel_props = f" {{{rel_str}}}"
        
        query = f"""
        MATCH (a:{start_label} {{{start_str}}})
        MATCH (b:{end_label} {{{end_str}}})
        CREATE (a)-[r:{rel_type}{rel_props}]->(b)
        RETURN r {{.*}} AS relationship
        """
        
        # Build parameters
        params = {f"start_{k}": v for k, v in start_match.items()}
        params.update({f"end_{k}": v for k, v in end_match.items()})
        if rel_properties:
            params.update({f"rel_{k}": v for k, v in rel_properties.items()})
        
        with self.driver.session() as session:
            result = session.run(query, params)
            record = result.single()
            if record:
                print(f"✓ Created {rel_type} relationship")
                return record['relationship']
            print(f"✗ Could not create relationship - nodes not found")
            return None
    
    # =========================================================================
    # READ OPERATIONS
    # =========================================================================
    
    def read_node(
        self,
        label: str,
        match_properties: Dict[str, Any]
    ) -> Optional[Dict[str, Any]]:
        """
        Read a single node by label and properties.
        
        Args:
            label: Node label
            match_properties: Properties to match
            
        Returns:
            Node properties or None if not found
        """
        match_str = " AND ".join([f"n.{k} = ${k}" for k in match_properties])
        
        query = f"""
        MATCH (n:{label})
        WHERE {match_str}
        RETURN n {{.*}} AS node
        LIMIT 1
        """
        
        with self.driver.session() as session:
            result = session.run(query, match_properties)
            record = result.single()
            return record['node'] if record else None
    
    def read_nodes(
        self,
        label: str,
        filters: Optional[Dict[str, Any]] = None,
        order_by: Optional[str] = None,
        limit: int = 100
    ) -> List[Dict[str, Any]]:
        """
        Read multiple nodes with optional filtering.
        
        Args:
            label: Node label
            filters: Optional property filters
            order_by: Property to order by
            limit: Maximum results
            
        Returns:
            List of node property dictionaries
        """
        query = f"MATCH (n:{label})"
        params = {'limit': limit}
        
        if filters:
            conditions = []
            for key, value in filters.items():
                param_name = f"filter_{key}"
                if isinstance(value, str) and '%' in value:
                    # Contains search
                    conditions.append(f"toLower(n.{key}) CONTAINS toLower(${param_name})")
                    params[param_name] = value.replace('%', '')
                else:
                    conditions.append(f"n.{key} = ${param_name}")
                    params[param_name] = value
            
            if conditions:
                query += f"\nWHERE {' AND '.join(conditions)}"
        
        query += "\nRETURN n {.*} AS node"
        
        if order_by:
            query += f"\nORDER BY n.{order_by}"
        
        query += "\nLIMIT $limit"
        
        with self.driver.session() as session:
            result = session.run(query, params)
            return [record['node'] for record in result]
    
    def read_with_relationships(
        self,
        start_label: str,
        start_match: Dict[str, Any],
        rel_type: str,
        end_label: str
    ) -> List[Dict[str, Any]]:
        """
        Read a node with its relationships.
        
        Args:
            start_label: Starting node label
            start_match: Properties to match start node
            rel_type: Relationship type to follow
            end_label: End node label
            
        Returns:
            List of results with start, relationship, and end properties
        """
        match_str = " AND ".join([f"a.{k} = ${k}" for k in start_match])
        
        query = f"""
        MATCH (a:{start_label})-[r:{rel_type}]->(b:{end_label})
        WHERE {match_str}
        RETURN a {{.*}} AS start_node,
               r {{.*}} AS relationship,
               b {{.*}} AS end_node
        """
        
        with self.driver.session() as session:
            result = session.run(query, start_match)
            return [dict(record) for record in result]
    
    def count_nodes(self, label: str) -> int:
        """Count nodes with a label."""
        query = f"MATCH (n:{label}) RETURN count(n) AS count"
        
        with self.driver.session() as session:
            result = session.run(query)
            return result.single()['count']
    
    def execute_query(
        self,
        query: str,
        parameters: Optional[Dict[str, Any]] = None
    ) -> List[Dict[str, Any]]:
        """
        Execute arbitrary Cypher query.
        
        Args:
            query: Cypher query string
            parameters: Query parameters
            
        Returns:
            List of result records as dictionaries
        """
        with self.driver.session() as session:
            result = session.run(query, parameters or {})
            return [dict(record) for record in result]
    
    # =========================================================================
    # UPDATE OPERATIONS
    # =========================================================================
    
    def update_node(
        self,
        label: str,
        match_properties: Dict[str, Any],
        update_properties: Dict[str, Any]
    ) -> Optional[Dict[str, Any]]:
        """
        Update a node's properties.
        
        Args:
            label: Node label
            match_properties: Properties to match the node
            update_properties: Properties to update
            
        Returns:
            Updated node properties or None
        """
        match_str = " AND ".join([f"n.{k} = $match_{k}" for k in match_properties])
        set_str = ", ".join([f"n.{k} = $update_{k}" for k in update_properties])
        
        query = f"""
        MATCH (n:{label})
        WHERE {match_str}
        SET {set_str}
        RETURN n {{.*}} AS node
        """
        
        params = {f"match_{k}": v for k, v in match_properties.items()}
        params.update({f"update_{k}": v for k, v in update_properties.items()})
        
        with self.driver.session() as session:
            result = session.run(query, params)
            record = result.single()
            if record:
                print(f"✓ Updated {label} node")
                return record['node']
            return None
    
    def add_labels(
        self,
        label: str,
        match_properties: Dict[str, Any],
        new_labels: List[str]
    ) -> bool:
        """
        Add labels to a node.
        
        Args:
            label: Current node label
            match_properties: Properties to match
            new_labels: Labels to add
            
        Returns:
            True if successful
        """
        # Validate labels
        for lbl in new_labels:
            if not lbl.isidentifier():
                raise ValueError(f"Invalid label: {lbl}")
        
        match_str = " AND ".join([f"n.{k} = ${k}" for k in match_properties])
        labels_str = ":".join(new_labels)
        
        query = f"""
        MATCH (n:{label})
        WHERE {match_str}
        SET n:{labels_str}
        RETURN n
        """
        
        with self.driver.session() as session:
            result = session.run(query, match_properties)
            if result.single():
                print(f"✓ Added labels: {new_labels}")
                return True
            return False
    
    def remove_property(
        self,
        label: str,
        match_properties: Dict[str, Any],
        property_name: str
    ) -> bool:
        """
        Remove a property from a node.
        
        Args:
            label: Node label
            match_properties: Properties to match
            property_name: Property to remove
            
        Returns:
            True if successful
        """
        match_str = " AND ".join([f"n.{k} = ${k}" for k in match_properties])
        
        query = f"""
        MATCH (n:{label})
        WHERE {match_str}
        REMOVE n.{property_name}
        RETURN n
        """
        
        with self.driver.session() as session:
            result = session.run(query, match_properties)
            if result.single():
                print(f"✓ Removed property: {property_name}")
                return True
            return False
    
    # =========================================================================
    # DELETE OPERATIONS
    # =========================================================================
    
    def delete_node(
        self,
        label: str,
        match_properties: Dict[str, Any],
        detach: bool = False
    ) -> int:
        """
        Delete a node.
        
        Args:
            label: Node label
            match_properties: Properties to match
            detach: If True, also delete relationships
            
        Returns:
            Number of nodes deleted
        """
        match_str = " AND ".join([f"n.{k} = ${k}" for k in match_properties])
        
        delete_clause = "DETACH DELETE n" if detach else "DELETE n"
        
        query = f"""
        MATCH (n:{label})
        WHERE {match_str}
        {delete_clause}
        RETURN count(n) AS deleted
        """
        
        try:
            with self.driver.session() as session:
                result = session.run(query, match_properties)
                count = result.single()['deleted']
                print(f"✓ Deleted {count} node(s)")
                return count
        except Neo4jError as e:
            if "still has relationships" in str(e):
                print("✗ Cannot delete: node has relationships. Use detach=True")
            else:
                print(f"✗ Error: {e}")
            return 0
    
    def delete_relationship(
        self,
        start_label: str,
        start_match: Dict[str, Any],
        rel_type: str,
        end_label: str,
        end_match: Dict[str, Any]
    ) -> int:
        """
        Delete a relationship.
        
        Args:
            start_label: Start node label
            start_match: Start node match properties
            rel_type: Relationship type
            end_label: End node label
            end_match: End node match properties
            
        Returns:
            Number of relationships deleted
        """
        start_str = " AND ".join([f"a.{k} = $start_{k}" for k in start_match])
        end_str = " AND ".join([f"b.{k} = $end_{k}" for k in end_match])
        
        query = f"""
        MATCH (a:{start_label})-[r:{rel_type}]->(b:{end_label})
        WHERE {start_str} AND {end_str}
        DELETE r
        RETURN count(r) AS deleted
        """
        
        params = {f"start_{k}": v for k, v in start_match.items()}
        params.update({f"end_{k}": v for k, v in end_match.items()})
        
        with self.driver.session() as session:
            result = session.run(query, params)
            count = result.single()['deleted']
            print(f"✓ Deleted {count} relationship(s)")
            return count
    
    def delete_all(self, label: str) -> int:
        """
        Delete all nodes with a label (and their relationships).
        
        WARNING: Destructive operation!
        
        Args:
            label: Label of nodes to delete
            
        Returns:
            Number of nodes deleted
        """
        query = f"""
        MATCH (n:{label})
        DETACH DELETE n
        RETURN count(n) AS deleted
        """
        
        with self.driver.session() as session:
            result = session.run(query)
            count = result.single()['deleted']
            print(f"⚠️ Deleted {count} {label} nodes")
            return count


# =============================================================================
# DEMONSTRATION
# =============================================================================

def demonstrate_crud():
    """Demonstrate all CRUD operations."""
    
    uri = os.getenv('NEO4J_URI', 'bolt://localhost:7687')
    user = os.getenv('NEO4J_USER', 'neo4j')
    password = os.getenv('NEO4J_PASSWORD', 'password')
    
    with QueryExecutor(uri, user, password) as executor:
        
        print("\n" + "=" * 60)
        print("CREATE OPERATIONS")
        print("=" * 60)
        
        # Create nodes
        patient = executor.create_node("TestPatient", {
            "id": "TP001",
            "name": "Test Patient",
            "active": True
        })
        print(f"Created: {patient}")
        
        # Merge node
        merged = executor.merge_node(
            "TestPatient",
            match_properties={"id": "TP001"},
            create_properties={"created_at": datetime.now().isoformat()},
            update_properties={"updated_at": datetime.now().isoformat()}
        )
        print(f"Merged: {merged}")
        
        # Create another node for relationship
        disease = executor.create_node("TestDisease", {
            "id": "TD001",
            "name": "Test Disease"
        })
        
        # Create relationship
        rel = executor.create_relationship(
            "TestPatient", {"id": "TP001"},
            "TestDisease", {"id": "TD001"},
            "TEST_REL",
            {"created": datetime.now().isoformat()}
        )
        
        print("\n" + "=" * 60)
        print("READ OPERATIONS")
        print("=" * 60)
        
        # Read single node
        node = executor.read_node("TestPatient", {"id": "TP001"})
        print(f"Read node: {node}")
        
        # Read with relationships
        rels = executor.read_with_relationships(
            "TestPatient", {"id": "TP001"},
            "TEST_REL", "TestDisease"
        )
        print(f"Relationships: {rels}")
        
        # Count
        count = executor.count_nodes("TestPatient")
        print(f"TestPatient count: {count}")
        
        print("\n" + "=" * 60)
        print("UPDATE OPERATIONS")
        print("=" * 60)
        
        # Update node
        updated = executor.update_node(
            "TestPatient",
            {"id": "TP001"},
            {"name": "Updated Test Patient", "status": "modified"}
        )
        print(f"Updated: {updated}")
        
        # Add labels
        executor.add_labels("TestPatient", {"id": "TP001"}, ["VIP", "Priority"])
        
        print("\n" + "=" * 60)
        print("DELETE OPERATIONS")
        print("=" * 60)
        
        # Delete relationship first
        executor.delete_relationship(
            "TestPatient", {"id": "TP001"},
            "TEST_REL",
            "TestDisease", {"id": "TD001"}
        )
        
        # Delete nodes
        executor.delete_node("TestPatient", {"id": "TP001"})
        executor.delete_node("TestDisease", {"id": "TD001"})
        
        print("\n✅ CRUD demonstration complete!")


if __name__ == "__main__":
    demonstrate_crud()
```

---

[Continued in next section...]

